週末了我卻還在這寫文章,真你他...嘿!歡迎回來!我絕對沒有在偷偷抱怨,昨天的 Day 4 我們成功地在本地建立了一個,呃,我們就先叫他資料庫吧!雖然不是很正式就是了,讓我們的頁面不再使用硬編碼而是整合一個由我們管理的題庫。現在我們的應用程式每次刷新都能看到不一樣的題目,總算有點樣子了,當然,我知道頁面還很醜、 API 回應還超慢,這我都知道,但別著急,一切都會好起來的。
不過,昨天如果你真的有做多次的測試,你應該會發現一個有些尷尬的的問題,當題目是一個程式實作題目時,例如:「實作一個 flatten 函數」這種程式題時,我們的作答區只是一個陽春的 ,場面瞬間尷尬起來。雖然實際面試時也是有考官會讓你直接在word文件之類的地方寫程式碼,但少了縮排、自動完成跟各種提示總覺得寫起來綁手綁腳的對吧!我們今天的目標就是讓程式題目的體驗不要再這麼悲催,給一個足夠規格的程式碼編輯界面讓人使用!
市面上有許多優秀的網頁程式碼編輯器,例如 CodeMirror、simple code editor之類的成熟套件。但考量到以下的優點,它確實會是我們專案相當適配的一個選項:
尤其在我們選用 Next.js 作為開發框架的前提下,Monaco Editor 配套的 @monaco-editor/react
在使用上極為容易,替我們處理完許多繁複的邏輯,在程式碼中可以極快看到不錯的效果,雖然也伴隨著一些效能上的隱憂(Monaco Editor 本身不算是個非常瘦身的套件),但權衡下優點還是遠大於缺點。了解這些後我們就可以開始今天實際的操作囉!
要在 React 專案中使用 Monaco Editor,最簡單的方式就是使用社群維護的 wrapper 套件。這讓我們可以像操作普通 React 元件一樣去使用它,也就是我們剛剛提到的@monaco-editor/react
。
在你的專案終端機中,執行以下指令:
npm install @monaco-editor/react
現在最關鍵的一步來了。我們不希望所有題目都用 Monaco Editor,概念題用原本的 就足夠了。我們需要讓作答區「變聰明」,根據題目類型顯示對應的元件。
打開 app/page.tsx
,我們來進行一些改造吧!這兩天我們都有對這個檔案做一些更動,若你真的嫌麻煩的話,可以直接複製以下的完整內容並貼上,一樣能在畫面中看到最新的進度,想確認今天新增的部分可以參考註解或是下方的補充說明。
'use client'; // 重要!告訴 Next.js 這是客戶端元件, 這樣才能使用 client-side 的 hooks
import { useState, useEffect } from 'react';
import { Question } from './types/question';
import Editor from '@monaco-editor/react'; // 引入 Editor 元件
export default function Home() {
const [answer, setAnswer] = useState('');
const [feedback, setFeedback] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null);
const [isFetchingQuestion, setIsFetchingQuestion] = useState(false);
useEffect(() => {
const fetchQuestion = async () => {
try {
setIsFetchingQuestion(true);
const response = await fetch('/api/questions');
const data = await response.json();
setCurrentQuestion(data);
// 如果是程式題,設定初始程式碼
if (data.type === 'code' && data.starterCode) {
setAnswer(data.starterCode);
} else {
setAnswer('');
}
} catch (error) {
console.error('無法抓取題目:', error);
} finally {
setIsFetchingQuestion(false);
}
};
fetchQuestion();
}, []);
const handleSubmit = async () => {
if (!answer) return;
try {
setIsLoading(true);
// 發送請求到我們剛剛在後端app/api/gemini/route.ts建立的API
const response = await fetch('/api/gemini', {
method: 'POST',
body: JSON.stringify({
question: currentQuestion?.question,
answer,
}),
});
const data = await response.json();
setFeedback(data.result);
} catch (error) {
console.error('錯誤:', error);
} finally {
setIsLoading(false);
}
};
const renderAnswerArea = () => {
if (!currentQuestion) return null;
if (currentQuestion.type === 'code') {
return (
<Editor
height="40vh"
language="javascript"
theme="vs-dark"
value={answer}
onChange={(value) => setAnswer(value || '')}
options={{
minimap: { enabled: false },
fontSize: 16,
}}
/>
);
} else {
return (
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
className="w-full h-32 bg-gray-700 rounded p-3 text-white"
placeholder="在這裡輸入你的答案..."
disabled={isLoading}
/>
);
}
};
return (
<main className="min-h-screen bg-gray-900 text-white">
<div className="container mx-auto px-4 py-16">
<h1 className="text-4xl font-bold text-center mb-8">
AI 前端面試官 🤖
</h1>
<div className="max-w-2xl mx-auto">
{/* 題目區 */}
<div className="bg-gray-800 rounded-lg p-6 mb-6">
{isFetchingQuestion ? (
<p className="text-center text-gray-400">正在從題庫抽取題目...</p>
) : (
currentQuestion && (
<>
<div className="text-sm text-blue-400 mb-2">
{currentQuestion.topic} 題目
</div>
<p className="text-lg">{currentQuestion.question}</p>
</>
)
)}
</div>
{/* 作答區 */}
<div className="bg-gray-800 rounded-lg p-6 mb-6">
{renderAnswerArea()}
</div>
{/* 按鈕 */}
<button
onClick={handleSubmit}
disabled={isLoading || !answer}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition-colors"
>
{isLoading ? '🤔 AI 思考中...' : '提交答案'}
</button>
{/* AI 回饋區 */}
<div className="bg-gray-800 rounded-lg p-6 mt-6">
<div className="text-sm text-green-400 mb-2">AI 回饋</div>
{feedback ? (
<div className="text-gray-300 whitespace-pre-wrap">
{feedback}
</div>
) : (
<p className="text-gray-400 italic">
提交答案後,AI 將在這裡提供回饋...
</p>
)}
</div>
</div>
</div>
</main>
);
}
@monaco-editor/react
引入了 Editor 元件,並加入幾個基本的 props 來讓編輯器做一些客製化,例如 height、language、theme,並在 options 中設置字體的大小讓體驗更舒適一點。renderAnswerArea
函式:我們建立了一個新的函式專門用來處理作答區的渲染邏輯。它會檢查 currentQuestion.type
,如果是 code
,就渲染 元件;否則,就渲染之前的 讓使用者依然可以輸入概念問題回答。starterCode
填入作答區。value={answer}
和 onChange={(value) => setAnswer(value || '')}
來雙向綁定 state。onChange 會將使用者輸入在編輯器的內容回傳給編輯器,之後我們就可以將使用者輸入的程式碼送出或執行。完成到這個步驟後,再次輸入
npm run dev
進入localhost:3000 看一下畫面,反覆重新整理直到你刷到程式題目出現,如下圖1的畫面:
![]() |
---|
圖1 :程式碼編輯器呈現 |
稍微把弄一下,你會發現程式碼提示、語法高亮等基本功能全部都有!就好像你一般在開發一樣自然!一切就是這麼的簡單!當然,實際上畫面還是很簡陋,但這些我們都會在 Day7 的整合中做畫面上的優化,現在你只要知道我們有編輯器用就行囉!
不過只有畫面是遠遠不夠的,在我們結束今天的內容之前,我們還得做最後一個確認:「我們是否真的能取得使用者輸入的程式碼」,在 Monaco Editor 取得編輯器內容的方式相當簡單,而且我們其實已經做出來了,也就是剛剛提到的雙向綁定,我們在使用者輸入程式碼的同時透過onChange
更新answer
變數,所以其實你只要回頭修改 handleSubmit
函式,在最前面加上一行 console.log
:
const handleSubmit = async () => {
if (!answer || !currentQuestion) return;
// 驗證步驟:在送出前印出目前的答案
console.log("準備提交的答案:", answer);
try {
setIsLoading(true);
// ... 後續 fetch 程式碼不變
}
//...
};
隨意在編輯器輸入任何程式碼後按下送出,你會發現輸入的程式碼確實被印出了。
![]() |
---|
圖2 :印出使用者輸入的程式碼 |
今天就這樣啦! 是不是覺得沒寫什麼東西但整個應用程式突然升級了不少? 很多時候其實就是這樣的,選好適合的工具後剩餘的東西都遠沒有這麼複雜,透過這個簡單的整合我們又離完整版更近一步了!再次回顧一下我們今天的進度吧:
✅ 了解了選擇 Monaco Editor 的理由
✅ 成功整合了 @monaco-editor/react
✅ 實作了根據題型動態切換作答區的智慧邏輯
✅ 確保了不論何種題型,都能正確地管理與提交答案
行有餘力的話你也可以稍微多玩玩今天用的套件,試著傳入不同的 props去做你理想中的客製化,可以在這邊看到完整提供的 props 。
現在我們的「面子」已經沒有這麼悲催了,但「裡子」——AI 的智慧,還停留在 Day 2 的水準。明天(Day 6),我們要來鑽研整個 AI 應用的靈魂:Prompt Engineering!我們將學習來自 Google、OpenAI、Anthropic 的官方技巧,對我們的 AI「劇本」進行第一次大升級,讓它的回饋變得更精準、更可靠!
我們明天見!🚀
今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-5